Univalle


1. Introducción

La capacidad de anticipar el comportamiento futuro de los precios en los mercados financieros constituye uno de los elementos centrales para la toma de decisiones de inversión estratégicas y la gestión eficiente de portafolios. En el contexto de los mercados bursátiles globales, donde la incertidumbre es inherente y los patrones de precios reflejan complejas dinámicas de oferta y demanda, el desarrollo de modelos predictivos robustos cobra particular relevancia para inversionistas, analistas financieros y gestores de cartera.

El Invesco \(QQQ Trust\) (QQQ) es uno de los fondos cotizados en bolsa (ETF) más negociados a nivel mundial, con activos bajo gestión superiores a los 400 mil millones de dólares. Este instrumento replica el índice \(Nasdaq-100\), compuesto por las 100 empresas no financieras de mayor capitalización bursátil listadas en el mercado Nasdaq, con una fuerte concentración en el sector tecnológico que incluye líderes como \(Apple\), \(Microsoft\), \(NVIDIA\), \(Amazon\) y \(Alphabet\). Esta composición convierte al \(QQQ\) en un referente para analizar el comportamiento del sector tecnológico estadounidense y, por extensión, las tendencias de innovación que impulsan la economía global.

La importancia de modelar series de precios como la del QQQ radica en múltiples factores. En primer lugar, los pronósticos financieros juegan un papel fundamental en la formación de expectativas de mercado. En segundo lugar, el análisis cuantitativo de series temporales permite identificar patrones que proporcionan información valiosa para diseñar estrategias de inversión fundamentadas. Dado que el sector tecnológico representa uno de los mercados emergentes con mayor potencial de crecimiento en la economía global, predecir el comportamiento del \(QQQ\) permite anticipar tendencias en innovación, evaluar correctamente la valuación de activos tecnológicos y gestionar exposición al riesgo en un sector de alta volatilidad.

El presente análisis aplica la metodología \(ARIMA\) (AutoRegressive Integrated Moving Average) al pronóstico de precios del \(QQQ\). Los modelos \(ARIMA\) se han consolidado como uno de los enfoques más utilizados para series temporales financieras, permitiendo capturar autocorrelaciones presentes en datos históricos mediante combinación de componentes autorregresivos, integrados y media móvil. El análisis incluye: (i) verificación de supuestos estadísticos fundamentales; (ii) selección del modelo óptimo mediante criterios de información; (iii) validación diagnóstica de residuos; y (iv) generación de pronósticos con cuantificación de incertidumbre.


2. Metodología

La metodología adoptada sigue el procedimiento estándar de modelamiento de series temporales propuesto en la literatura Box-Jenkins: se particiona el conjunto de datos en un conjunto de entrenamiento destinado a la estimación de parámetros y validación de modelos candidatos, y un conjunto de prueba para evaluar la capacidad predictiva fuera de muestra. Este diseño respeta la naturaleza secuencial de datos financieros, evitando la fuga de información que comprometería la validez de los pronósticos.

2.1 Base de Datos

La base de datos utilizada proviene de Yahoo Finance, plataforma reconocida globalmente para obtención de datos financieros históricos de alta calidad y frecuencia diaria. Se extrajo información del ETF Invesco QQQ Trust (símbolo: QQQ), el cual replica el índice Nasdaq-100 compuesto por las 100 empresas no financieras de mayor capitalización bursátil listadas en el mercado Nasdaq, con fuerte concentración en sector tecnológico.

La extracción se realizó mediante la función getSymbols() del paquete quantmod de R, automatizando la descarga directa desde fuente oficial. El período de estudio abarca desde 7 de octubre de 2022 hasta 2 de diciembre de 2025, capturando 791 observaciones diarias de precios de cierre. Este período es particularmente informativo: incluye recuperación post-crisis 2022, impulso del rally de inteligencia artificial, cambios en política monetaria de la Reserva Federal, y volatilidad estructural del sector tecnológico.

2.2 Variable de Análisis

El dataset se centra en una única variable cuantitativa de interés: precio de cierre diario del ETF QQQ. Esta variable representa el último precio de negociación durante cada sesión bursátil y es la medida estándar en análisis técnico y modelización de series financieras. La elección se fundamenta en que refleja consenso del mercado al finalizar cada sesión, incorporando información completa del día.

2.3 Partición Temporal de Datos

En análisis de series temporales, la partición de datos debe respetar el orden cronológico. La estrategia implementada divide la serie en conjunto de entrenamiento (aproximadamente 95% de datos) para identificar parámetros óptimos, y conjunto de prueba (aproximadamente 5% de datos) para evaluar capacidad predictiva en escenario realista. Esta metodología evita fuga de información y mantiene estructura cronológica.

2.4 Marco Conceptual: Modelos ARIMA(p,d,q)

Los modelos ARIMA, desarrollados por Box y Jenkins en 1970, constituyen metodología sistemática para análisis y pronóstico de series temporales. Se fundamentan en la idea de que el valor actual de una serie puede explicarse mediante sus valores históricos (AR), transformaciones que alcancen estacionariedad (I), o errores de pronóstico pasados (MA).

Componentes ARIMA(p,d,q)

  • p (Autorregresivo): Número de valores pasados de la serie utilizados para predecir valor actual. El componente AR(p) captura persistencia: \(y_t = c + \phi_1 y_{t-1} + \cdots + \phi_p y_{t-p} + \varepsilon_t\)

  • d (Integración): Número de diferencias necesarias para alcanzar estacionariedad. Una serie no-estacionaria se transforma: \(y'_t = y_t - y_{t-1}\)

  • q (Media Móvil): Número de errores de pronóstico pasados incorporados al modelo. El componente MA(q) captura efectos transitorios de shocks: \(y_t = c + \varepsilon_t + \theta_1 \varepsilon_{t-1} + \cdots + \theta_q \varepsilon_{t-q}\)

Supuestos Fundamentales

  • Estacionariedad de serie temporal después de diferenciación
  • Residuos comportándose como ruido blanco (media cero, varianza constante, independencia)
  • Homocedasticidad de errores
  • Aproximación a normalidad de residuos (deseable pero no estricta)

2.5 Herramientas Estadísticas para Identificación y Validación

Prueba Aumentada de Dickey-Fuller (ADF): Contrasta hipótesis nula de no-estacionariedad (raíz unitaria) contra alternativa de estacionariedad. El resultado determina orden de integración \(d\).

Funciones de Autocorrelación (ACF y PACF): Proporcionan diagnóstico visual de dependencia temporal. ACF sugiere orden del componente MA; PACF sugiere orden del componente AR.

Criterio de Información de Akaike Corregido (AICc): Balancea calidad de ajuste con complejidad del modelo: \(\text{AICc} = \text{AIC} + \frac{2(p+q+k+1)(p+q+k+2)}{T-p-q-k-2}\). Especificaciones con menor AICc son preferibles. Nota crítica: AICc no es comparable entre modelos con diferente \(d\); el parámetro \(d\) se determina primero mediante pruebas de estacionariedad.

Prueba de Ljung-Box: Evalúa si existe autocorrelación significativa en residuos. Un p-valor elevado (> 0.05) indica que residuos se comportan como ruido blanco independiente, validando adecuación del modelo.

2.6 Procedimiento Metodológico: Metodología Box-Jenkins

  1. Determinación de d: Mediante ADF y análisis visual de ACF, se identifica número de diferencias necesarias
  2. Análisis de ACF/PACF: Se examinan gráficos de serie diferenciada para obtener sugerencias sobre p y q
  3. Identificación de candidatos: Se identifican especificaciones plausibles y se ajustan múltiples modelos
  4. Selección: Se comparan mediante AICc, RMSE, MAE, MAPE
  5. Validación: Se examinan residuos mediante gráficos y pruebas estadísticas (Ljung-Box)
  6. Pronóstico: Se generan predicciones con intervalos de confianza

2.7 Métricas de Evaluación del Desempeño Predictivo

RMSE: \(\text{RMSE} = \sqrt{\frac{1}{n} \sum_{t=1}^{n} (\hat{y}_t - y_t)^2}\) - Amplifica errores grandes

MAE: \(\text{MAE} = \frac{1}{n} \sum_{t=1}^{n} |\hat{y}_t - y_t|\) - Menos sensible a outliers

MAPE: \(\text{MAPE} = \frac{100}{n} \sum_{t=1}^{n} \left| \frac{y_t - \hat{y}_t}{y_t} \right|\) - Error relativo en porcentaje


3. Descripción de la Serie Temporal

3.1 Contexto Histórico y Datos

serie_QQQ <- getSymbols("QQQ", src="yahoo", auto.assign=FALSE, from="2015-01-01") 
Precio <- serie_QQQ$`QQQ.Close`

3.2 Gráfico Dinámico de la Serie

datos_qqq <- data.frame(
  Fecha = index(Precio),
  Precio = as.numeric(Precio)
)

datos_qqq <- datos_qqq %>%
  mutate(Corte = as.yearqtr(Fecha)) 

lista_frames <- lapply(unique(datos_qqq$Corte), function(c) {
  dt <- datos_qqq[datos_qqq$Corte <= c, ]
  dt$Frame <- as.character(c) 
  return(dt)
})

datos_animados <- dplyr::bind_rows(lista_frames)

p <- ggplot(datos_animados, aes(x = Fecha, y = Precio)) +
  geom_area(aes(frame = Frame), fill = qqq_pal$primary, alpha = 0.1, position = "identity") +
  geom_line(aes(frame = Frame), color = qqq_pal$primary, size = 0.8) +
  labs(
    title = "Evolución Dinámica del QQQ",
    subtitle = "Crecimiento histórico acumulado desde octubre 2022",
    x = "", 
    y = "Precio (USD)"
  ) +
  scale_y_continuous(labels = scales::dollar_format()) +
  theme_QQQ() +
  theme(plot.title = element_text(size = 14))

plotly::ggplotly(p, tooltip = c("x", "y")) %>%
  plotly::layout(
    paper_bgcolor = 'rgba(0,0,0,0)',
    plot_bgcolor = 'rgba(0,0,0,0)',
    font = list(family = "Inter, sans-serif", color = qqq_pal$text_gray),
    hovermode = "x unified"
  ) %>%
  plotly::animation_opts(frame = 100, transition = 0, redraw = FALSE) %>%
  plotly::animation_slider(currentvalue = list(prefix = "Período: ")) %>%
  plotly::config(displayModeBar = FALSE)

4. Resultados del Modelo ARIMA

4.1 Partición de Datos

4.1.1 Estrategia Train/Test

El análisis emplea partición temporal que divide observaciones en dos subconjuntos contiguos. El punto de división se establece en 30 de septiembre de 2025, coincidiendo con cierre administrativo del tercer trimestre. Esta fecha marca quiebre natural en calendarios financieros, evitando arbitrariedades.

Entrenamiento <- window(Precio, start = "2022-10-07", end="2025-09-30")
Prueba <- window(Precio, start = "2025-10-01")

4.1.2 Tabla Resumen: Observaciones por Conjunto

particion_resumen <- data.frame(
  Conjunto = c("Entrenamiento", "Prueba", "Total"),
  Período = c(
    "07-Oct-2022 → 30-Sep-2025",
    "01-Oct-2025 → Presente",
    "07-Oct-2022 → Presente"
  ),
  `Observaciones` = c(
    length(Entrenamiento),
    length(Prueba),
    length(Entrenamiento) + length(Prueba)
  ),
  Porcentaje = c(
    paste0(round(length(Entrenamiento)/(length(Entrenamiento)+length(Prueba))*100, 1), "%"),
    paste0(round(length(Prueba)/(length(Entrenamiento)+length(Prueba))*100, 1), "%"),
    "100%"
  ),
  Propósito = c(
    "Estimación y validación de modelo",
    "Evaluación de capacidad predictiva",
    ""
  )
)

kable(particion_resumen,
      caption = "Resumen de Partición de Datos: Entrenamiento vs Prueba",
      align = c("l", "c", "c", "c", "l")) %>%
  kable_styling(bootstrap_options = c("hover", "condensed"),
                full_width = FALSE,
                position = "center") %>%
  row_spec(0, background = qqq_pal$primary, color = "white", bold = TRUE) %>%
  column_spec(1, bold = TRUE, color = qqq_pal$primary) %>%
  column_spec(3, bold = TRUE, color = qqq_pal$positive) %>%
  row_spec(3, bold = TRUE, background = "#e8f5e9", color = qqq_pal$text_dark)
Resumen de Partición de Datos: Entrenamiento vs Prueba
Conjunto Período Observaciones Porcentaje Propósito
Entrenamiento 07-Oct-2022 → 30-Sep-2025 747 94.3% Estimación y validación de modelo
Prueba 01-Oct-2025 → Presente 45 5.7% Evaluación de capacidad predictiva
Total 07-Oct-2022 → Presente 792 100%

4.1.3 Visualización: Serie con Partición

df_train <- data.frame(
  Fecha = index(Entrenamiento),
  Precio = as.numeric(Entrenamiento),
  Conjunto = "Entrenamiento"
)

df_test <- data.frame(
  Fecha = index(Prueba),
  Precio = as.numeric(Prueba),
  Conjunto = "Prueba"
)

df_completo <- bind_rows(df_train, df_test)
fecha_corte <- as.Date("2025-10-01")

ggplot(df_completo, aes(x = Fecha, y = Precio)) +
  geom_ribbon(data = df_train, 
              aes(ymin = min(df_completo$Precio) * 0.95, ymax = Precio),
              fill = qqq_pal$primary, alpha = 0.08) +
  geom_ribbon(data = df_test, 
              aes(ymin = min(df_completo$Precio) * 0.95, ymax = Precio),
              fill = qqq_pal$secondary, alpha = 0.15) +
  geom_line(data = df_train, color = qqq_pal$primary, linewidth = 0.9) +
  geom_line(data = df_test, color = qqq_pal$secondary, linewidth = 1.1) +
  geom_vline(xintercept = fecha_corte, 
             linetype = "dashed", color = qqq_pal$negative, linewidth = 0.8) +
  annotate("text", x = fecha_corte, y = max(df_completo$Precio) * 1.02,
           label = "Corte: 01-Oct-2025", hjust = -0.05, vjust = 0,
           color = qqq_pal$negative, fontface = "bold", size = 3.5) +
  annotate("label", 
           x = as.Date("2024-01-01"), 
           y = max(df_completo$Precio) * 0.85,
           label = paste0("ENTRENAMIENTO\n", nrow(df_train), " observaciones"),
           fill = qqq_pal$primary, color = "white", 
           fontface = "bold", size = 3.5, label.padding = unit(0.5, "lines")) +
  annotate("label", 
           x = max(df_test$Fecha) - 10,
           y = min(df_completo$Precio) * 1.15,
           label = paste0("PRUEBA\n", nrow(df_test), " obs."),
           fill = qqq_pal$secondary, color = "white", 
           fontface = "bold", size = 3.2, label.padding = unit(0.4, "lines")) +
  scale_x_date(date_breaks = "4 months", date_labels = "%b %Y",
               expand = expansion(mult = c(0.02, 0.05))) +
  scale_y_continuous(labels = dollar_format(prefix = "$"),
                     expand = expansion(mult = c(0.05, 0.08))) +
  labs(
    title = "Partición de Datos: Entrenamiento vs Prueba",
    subtitle = "QQQ (Nasdaq-100 ETF) | Serie de precios de cierre diarios",
    x = NULL,
    y = "Precio de Cierre (USD)",
    caption = paste0("Fuente: Yahoo Finance | Período: ", 
                     min(df_completo$Fecha), " a ", max(df_completo$Fecha))
  ) +
  theme_QQQ() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

La partición temporal establecida asegura cumplimiento con requerimientos académicos de mínimo 60 períodos de entrenamiento: el conjunto cuenta con 746 observaciones diarias (aproximadamente 3 años de negociación). El conjunto de prueba proporciona horizonte para validar pronósticos con márgenes de seguridad estadística.


4.2 Análisis de Estacionariedad

Aplicamos el procedimiento Box-Jenkins paso 1: evaluación formal de estacionariedad de serie en niveles. Esta determinación es crítica porque especifica el parámetro \(d\) del modelo ARIMA.

4.2.1 Serie en Niveles: Evidencia Visual

La serie original de precios del QQQ exhibe patrón visual de tendencia alcista con fluctuaciones alrededor de trayectoria creciente (Gráfico 1 anterior). El análisis de autocorrelación revela el síntoma clásico de no-estacionariedad: decaimiento lento de autocorrelaciones hacia cero.

acf_data <- acf(Entrenamiento, lag.max = 30, plot = FALSE)

df_acf <- data.frame(
  Lag = acf_data$lag[-1], 
  ACF = acf_data$acf[-1]
)

n <- length(Entrenamiento)
limite_sup <- qnorm(0.975) / sqrt(n)
limite_inf <- -limite_sup

ggplot(df_acf, aes(x = Lag, y = ACF)) +
  geom_segment(aes(xend = Lag, yend = 0), 
               color = qqq_pal$primary, linewidth = 0.8) +
  geom_point(color = qqq_pal$primary, size = 2) +
  geom_hline(yintercept = limite_sup, linetype = "dashed", 
             color = qqq_pal$secondary, linewidth = 0.7) +
  geom_hline(yintercept = limite_inf, linetype = "dashed", 
             color = qqq_pal$secondary, linewidth = 0.7) +
  geom_hline(yintercept = 0, color = qqq_pal$text_gray, linewidth = 0.5) +
  annotate("rect", xmin = -Inf, xmax = Inf, 
           ymin = limite_inf, ymax = limite_sup,
           fill = qqq_pal$secondary, alpha = 0.1) +
  annotate("label", x = 20, y = 0.5,
           label = "Decaimiento lento\n→ Serie NO estacionaria",
           fill = qqq_pal$negative, color = "white",
           fontface = "bold", size = 3.5, label.padding = unit(0.5, "lines")) +
  scale_x_continuous(breaks = seq(0, 30, 5)) +
  scale_y_continuous(limits = c(-0.1, 1.05), breaks = seq(0, 1, 0.25)) +
  labs(
    title = "Función de Autocorrelación (ACF) - Serie en Niveles",
    subtitle = "QQQ: Precio de cierre | Datos de entrenamiento",
    x = "Rezago (Lag)",
    y = "Autocorrelación",
    caption = "Bandas azules: Límites de significancia al 95%"
  ) +
  theme_QQQ()

La autocorrelación muestral permanece elevada (>0.85) incluso en rezagos distantes (lag 30). Este decaimiento lento es indicador clásico de presencia de raíz unitaria, reflejando que información reciente en precios tiene efecto permanente sobre niveles futuros. La mayoría de rezagos cae fuera de bandas de confianza, confirmando correlación sistemática estructural en serie.

4.2.2 Prueba Formal: Test de Dickey-Fuller Aumentado

adf_resultado <- adf.test(Entrenamiento)

tabla_adf <- data.frame(
  Métrica = c("Estadístico Dickey-Fuller", 
              "Orden de Rezagos (Lag)", 
              "P-valor",
              "Nivel de Significancia (α)",
              "Decisión"),
  Valor = c(round(adf_resultado$statistic, 4),
            adf_resultado$parameter,
            round(adf_resultado$p.value, 4),
            "0.05",
            ifelse(adf_resultado$p.value > 0.05, 
                   "No rechazar H₀: NO estacionaria", "Rechazar H₀: Estacionaria")),
  Interpretación = c("Valor del estadístico de prueba",
                     "Rezagos incluidos en el test",
                     "Probabilidad bajo H₀",
                     "Umbral de decisión",
                     ifelse(adf_resultado$p.value > 0.05,
                            "Aplicar diferenciación",
                            "Serie ya estacionaria ✓"))
)

kable(tabla_adf, 
      caption = "Prueba de Dickey-Fuller Aumentada (ADF) - Serie en Niveles",
      align = c("l", "c", "l")) %>%
  kable_styling(bootstrap_options = c("hover", "condensed"),
                full_width = FALSE,
                position = "center") %>%
  row_spec(0, background = qqq_pal$primary, color = "white", bold = TRUE) %>%
  row_spec(5, bold = TRUE, background = "#fef3f2", color = qqq_pal$text_dark)
Prueba de Dickey-Fuller Aumentada (ADF) - Serie en Niveles
Métrica Valor Interpretación
Estadístico Dickey-Fuller -3.0468 Valor del estadístico de prueba
Orden de Rezagos (Lag) 9 Rezagos incluidos en el test
P-valor 0.1352 Probabilidad bajo H₀
Nivel de Significancia (α) 0.05 Umbral de decisión
Decisión No rechazar H₀: NO estacionaria Aplicar diferenciación

Con p-valor de 0.1352 > 0.05, no se rechaza hipótesis nula: la serie de precios en niveles es no-estacionaria. Esta evidencia estadística justifica aplicación de diferenciación. El estadístico ADF de -3.0468 es mayor en magnitud que valor crítico aproximado de -3.43, confirmando presencia de raíz unitaria.

4.2.3 Transformación mediante Diferenciación

La diferenciación de primer orden transforma la serie de precios absolutos en cambios diarios: \(\nabla y_t = y_t - y_{t-1}\). Esta transformación remueve tendencia alcista de largo plazo.

dif_Entrenamiento <- diff(Entrenamiento) %>% na.omit()
df_diff <- data.frame(
  Fecha = index(dif_Entrenamiento),
  Valor = as.numeric(dif_Entrenamiento)
)

ggplot(df_diff, aes(x = Fecha, y = Valor)) +
  geom_line(color = qqq_pal$secondary, linewidth = 0.6) +
  geom_hline(yintercept = 0, linetype = "dashed", 
             color = qqq_pal$primary, linewidth = 0.7) +
  annotate("label", 
           x = as.Date("2023-06-01"), 
           y = max(df_diff$Valor) * 0.85,
           label = paste0("Media ≈ ", round(mean(df_diff$Valor), 3)),
           fill = qqq_pal$primary, color = "white",
           fontface = "bold", size = 3.5, label.padding = unit(0.4, "lines")) +
  scale_x_date(date_breaks = "4 months", date_labels = "%b %Y",
               expand = expansion(mult = c(0.02, 0.03))) +
  scale_y_continuous(labels = scales::dollar_format(prefix = "$"),
                     expand = expansion(mult = c(0.05, 0.08))) +
  labs(
    title = "Serie Diferenciada de Primer Orden (d = 1)",
    subtitle = "QQQ: Cambios diarios en precio de cierre | Datos de entrenamiento",
    x = NULL,
    y = "Cambio Diario (USD)",
    caption = paste0("Observaciones: ", nrow(df_diff), 
                     " | Período: ", min(df_diff$Fecha), " a ", max(df_diff$Fecha))
  ) +
  theme_QQQ() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

La serie diferenciada oscila alrededor de media próxima a cero (0.444), sin tendencia visual evidente. La volatilidad varía a lo largo del período—períodos de calma alternando con volatilidad elevada—pero la media permanece relativamente estable, característica fundamental de estacionariedad. Este contraste con el gráfico original es dramático: la transformación ha removido efectivamente la tendencia.

4.2.4 Verificación Post-Diferenciación

acf_diff_data <- acf(dif_Entrenamiento, lag.max = 30, plot = FALSE)

df_acf_diff <- data.frame(
  Lag = acf_diff_data$lag[-1],
  ACF = acf_diff_data$acf[-1]
)

n_diff <- length(dif_Entrenamiento)
limite_sup_diff <- qnorm(0.975) / sqrt(n_diff)
limite_inf_diff <- -limite_sup_diff

ggplot(df_acf_diff, aes(x = Lag, y = ACF)) +
  geom_segment(aes(xend = Lag, yend = 0), 
               color = qqq_pal$secondary, linewidth = 0.8) +
  geom_point(color = qqq_pal$secondary, size = 2) +
  geom_hline(yintercept = limite_sup_diff, linetype = "dashed", 
             color = qqq_pal$primary, linewidth = 0.7) +
  geom_hline(yintercept = limite_inf_diff, linetype = "dashed", 
             color = qqq_pal$primary, linewidth = 0.7) +
  geom_hline(yintercept = 0, color = qqq_pal$text_gray, linewidth = 0.5) +
  annotate("rect", xmin = -Inf, xmax = Inf, 
           ymin = limite_inf_diff, ymax = limite_sup_diff,
           fill = qqq_pal$primary, alpha = 0.1) +
  annotate("label", x = 22, y = 0.12,
           label = "Autocorrelaciones dentro\nde bandas → Estacionaria ✓",
           fill = qqq_pal$positive, color = "white",
           fontface = "bold", size = 3.5, label.padding = unit(0.5, "lines")) +
  scale_x_continuous(breaks = seq(0, 30, 5)) +
  scale_y_continuous(limits = c(-0.15, 0.2), breaks = seq(-0.1, 0.2, 0.05)) +
  labs(
    title = "Función de Autocorrelación (ACF) - Serie Diferenciada",
    subtitle = "QQQ: Cambios diarios | Verificación de estacionariedad post-diferenciación",
    x = "Rezago (Lag)",
    y = "Autocorrelación",
    caption = "Bandas verdes: Límites de significancia al 95%"
  ) +
  theme_QQQ()

La mayoría de autocorrelaciones caen dentro de bandas de confianza. Solo algunos rezagos (principalmente lag 1, 18, 20) muestran significancia marginal, pero patrón general indica ausencia de raíz unitaria. Este contraste con el ACF de serie original es dramático y valida la diferenciación.

adf_diff_resultado <- adf.test(dif_Entrenamiento)

tabla_adf_diff <- data.frame(
  Métrica = c("Estadístico Dickey-Fuller", 
              "Orden de Rezagos (Lag)", 
              "P-valor",
              "Decisión"),
  Valor = c(round(adf_diff_resultado$statistic, 4),
            adf_diff_resultado$parameter,
            round(adf_diff_resultado$p.value, 4),
            ifelse(adf_diff_resultado$p.value < 0.05, 
                   "Rechazar H₀: Serie estacionaria ✓", "No rechazar H₀: Aún no-estacionaria")),
  Interpretación = c("Valor del estadístico de prueba",
                     "Rezagos incluidos en el test",
                     "Probabilidad bajo H₀",
                     ifelse(adf_diff_resultado$p.value < 0.05,
                            "d=1 es suficiente",
                            "Considerar d=2"))
)

kable(tabla_adf_diff, 
      caption = "Prueba de Dickey-Fuller Aumentada (ADF) - Serie Diferenciada (d=1)",
      align = c("l", "c", "l")) %>%
  kable_styling(bootstrap_options = c("hover", "condensed"),
                full_width = FALSE,
                position = "center") %>%
  row_spec(0, background = qqq_pal$primary, color = "white", bold = TRUE) %>%
  row_spec(3, bold = TRUE, background = "#d4edda", color = qqq_pal$text_dark)
Prueba de Dickey-Fuller Aumentada (ADF) - Serie Diferenciada (d=1)
Métrica Valor Interpretación
Estadístico Dickey-Fuller -8.6831 Valor del estadístico de prueba
Orden de Rezagos (Lag) 9 Rezagos incluidos en el test
P-valor 0.01 Probabilidad bajo H₀
Decisión Rechazar H₀: Serie estacionaria ✓ d=1 es suficiente

El estadístico ADF de -8.6831 es altamente negativo, muy inferior al valor crítico de -3.43. Con p-valor de 0.01 < 0.05, se rechaza hipótesis nula con confianza. La serie diferenciada es estacionaria. Por tanto, el parámetro de integración es \(d=1\): una única diferenciación convierte la serie no-estacionaria de precios en serie estacionaria de cambios diarios.


4.3 Identificación del Modelo

Aplicamos paso 2 de Box-Jenkins: análisis visual de ACF y PACF de serie diferenciada para identificar órdenes \(p\) y \(q\) que caracterizan estructura de dependencia temporal.

4.3.1 Análisis ACF/PACF de Serie Diferenciada

acf_data <- acf(dif_Entrenamiento, lag.max = 28, plot = FALSE)
pacf_data <- pacf(dif_Entrenamiento, lag.max = 28, plot = FALSE)

df_acf <- data.frame(
  Lag = as.numeric(acf_data$lag[-1]),
  Valor = as.numeric(acf_data$acf[-1])
)

df_pacf <- data.frame(
  Lag = as.numeric(pacf_data$lag),
  Valor = as.numeric(pacf_data$acf)
)

n <- length(dif_Entrenamiento)
limite <- qnorm(0.975) / sqrt(n)

p_acf <- ggplot(df_acf, aes(x = Lag, y = Valor)) +
  geom_hline(yintercept = 0, color = qqq_pal$text_gray, linewidth = 0.5) +
  geom_hline(yintercept = c(-limite, limite), linetype = "dashed", 
             color = qqq_pal$secondary, linewidth = 0.6) +
  annotate("rect", xmin = -Inf, xmax = Inf, ymin = -limite, ymax = limite,
           fill = qqq_pal$secondary, alpha = 0.08) +
  geom_segment(aes(xend = Lag, yend = 0), color = qqq_pal$primary, linewidth = 0.7) +
  geom_point(color = qqq_pal$primary, size = 1.5) +
  scale_x_continuous(breaks = seq(0, 28, 5)) +
  scale_y_continuous(limits = c(-0.15, 0.12)) +
  labs(title = "ACF - Serie Diferenciada",
       subtitle = "Identificación del orden q (MA)",
       x = "Rezago (Lag)",
       y = "ACF") +
  theme_QQQ() +
  theme(plot.title = element_text(size = 12))

p_pacf <- ggplot(df_pacf, aes(x = Lag, y = Valor)) +
  geom_hline(yintercept = 0, color = qqq_pal$text_gray, linewidth = 0.5) +
  geom_hline(yintercept = c(-limite, limite), linetype = "dashed", 
             color = qqq_pal$secondary, linewidth = 0.6) +
  annotate("rect", xmin = -Inf, xmax = Inf, ymin = -limite, ymax = limite,
           fill = qqq_pal$secondary, alpha = 0.08) +
  geom_segment(aes(xend = Lag, yend = 0), color = qqq_pal$primary, linewidth = 0.7) +
  geom_point(color = qqq_pal$primary, size = 1.5) +
  scale_x_continuous(breaks = seq(0, 28, 5)) +
  scale_y_continuous(limits = c(-0.15, 0.12)) +
  labs(title = "PACF - Serie Diferenciada",
       subtitle = "Identificación del orden p (AR)",
       x = "Rezago (Lag)",
       y = "PACF") +
  theme_QQQ() +
  theme(plot.title = element_text(size = 12))

grid.arrange(p_acf, p_pacf, ncol = 2)

El análisis visual de ACF y PACF de la serie diferenciada del QQQ revela patrones consistentes con mercados financieros eficientes. En el ACF, la mayoría de autocorrelaciones caen dentro de las bandas de confianza teóricas para todos los rezagos hasta lag 28. Esta ausencia sistemática de autocorrelaciones significativas es consistente con la hipótesis de mercado eficiente (EMH): los precios de activos incorporan información disponible públicamente, dejando en los retornos estructura prácticamente aleatoria sin dependencias predecibles. La presencia de algunas autocorrelaciones leves (por ejemplo, alrededor de lag 17-20) no alcanza magnitud para interpretarse como estructura genuina.

El PACF exhibe patrón complementario: nuevamente, mayoría de autocorrelaciones parciales se ubica dentro de bandas de confianza, con excepción de quizás lag 1 que muestra pequeña autocorrelación parcial negativa. En conjunto, el análisis ACF/PACF sugiere que la serie diferenciada es cercana a ruido blanco: secuencia de innovaciones aleatorias sin estructura de dependencia predecible.

4.3.2 Modelos ARIMA Candidatos

tabla_candidatos <- data.frame(
  Modelo = c("ARIMA(0,1,0)", 
             "ARIMA(1,1,1)",
             "ARIMA(2,1,1)", 
             "ARIMA(1,1,2)",
             "ARIMA(2,1,2)",
             "ARIMA(3,1,3)"),
  Observación = c(
    "Random walk: benchmark de mercado eficiente",
    "Selección automática por auto.arima()",
    "Extensión AR(2) para persistencia de corto plazo",
    "Extensión MA(2) para estructura de media móvil",
    "Combinación simétrica de dinámicas AR y MA",
    "Exploratorio: rezagos marginales en ambos correlogramas"
  ),
  Justificación = c(
    "Si este modelo es óptimo: mercado es eficiente",
    "Referencia algorítmica para validar selección manual",
    "Captura si existen 2 rezagos de persistencia genuina",
    "Captura si shocks tienen 2 períodos de memoria",
    "Modelo más flexible para capturar dinámicas complejas",
    "Evalúa si picos en lag 3 son ruido vs estructura"
  )
)

kable(tabla_candidatos,
      caption = "Modelos ARIMA Candidatos para Evaluación",
      align = c("l", "l", "l")) %>%
  kable_styling(bootstrap_options = c("hover", "condensed"),
                full_width = FALSE,
                position = "center") %>%
  row_spec(0, background = qqq_pal$primary, color = "white", bold = TRUE) %>%
  column_spec(1, bold = TRUE, color = qqq_pal$primary) %>%
  row_spec(2, background = "#e8f5e9", color = qqq_pal$text_dark, bold = TRUE)
Modelos ARIMA Candidatos para Evaluación
Modelo Observación Justificación
ARIMA(0,1,0) Random walk: benchmark de mercado eficiente Si este modelo es óptimo: mercado es eficiente
ARIMA(1,1,1) Selección automática por auto.arima() Referencia algorítmica para validar selección manual
ARIMA(2,1,1) Extensión AR(2) para persistencia de corto plazo Captura si existen 2 rezagos de persistencia genuina
ARIMA(1,1,2) Extensión MA(2) para estructura de media móvil Captura si shocks tienen 2 períodos de memoria
ARIMA(2,1,2) Combinación simétrica de dinámicas AR y MA Modelo más flexible para capturar dinámicas complejas
ARIMA(3,1,3) Exploratorio: rezagos marginales en ambos correlogramas Evalúa si picos en lag 3 son ruido vs estructura

La selección de estos seis modelos candidatos obedece a lógica progresiva de complejidad. El ARIMA(0,1,0)—random walk—representa hipótesis nula de mercado eficiente: no existe estructura explorable. El ARIMA(1,1,1) fue seleccionado por auto.arima() mediante algoritmos de búsqueda con criterios de información. Los modelos restantes exploran extensiones sistemáticas de órdenes p y q para determinar si estructura adicional existe.


4.4 Estimación y Comparación de Modelos

Aplicamos paso 3 y 4 de Box-Jenkins: ajuste de modelos candidatos y selección mediante AICc, complementada con métricas de precisión.

4.4.1 Ajuste de Modelos y Comparación por Criterios de Información

ModeloQA <- auto.arima(Entrenamiento)
modeloQ1 <- Arima(Entrenamiento, order = c(3,1,3))
modeloQ2 <- Arima(Entrenamiento, order = c(0,1,0))
modeloQ3 <- Arima(Entrenamiento, order = c(2,1,1))
modeloQ4 <- Arima(Entrenamiento, order = c(1,1,2))
modeloQ5 <- Arima(Entrenamiento, order = c(2,1,2))
comparacion_IC <- data.frame(
  Modelo = c("ARIMA(0,1,0)", 
             "ARIMA(1,1,1) + drift", 
             "ARIMA(2,1,1)", 
             "ARIMA(1,1,2)",
             "ARIMA(2,1,2)",
             "ARIMA(3,1,3)"),
  Parametros = c(length(coef(modeloQ2)) + 1,
                 length(coef(ModeloQA)) + 1,
                 length(coef(modeloQ3)) + 1,
                 length(coef(modeloQ4)) + 1,
                 length(coef(modeloQ5)) + 1,
                 length(coef(modeloQ1)) + 1),
  AIC = round(c(AIC(modeloQ2), 
                AIC(ModeloQA), 
                AIC(modeloQ3), 
                AIC(modeloQ4),
                AIC(modeloQ5),
                AIC(modeloQ1)), 2),
  AICc = round(c(modeloQ2$aicc, 
                 ModeloQA$aicc, 
                 modeloQ3$aicc, 
                 modeloQ4$aicc,
                 modeloQ5$aicc,
                 modeloQ1$aicc), 2),
  BIC = round(c(BIC(modeloQ2), 
                BIC(ModeloQA), 
                BIC(modeloQ3), 
                BIC(modeloQ4),
                BIC(modeloQ5),
                BIC(modeloQ1)), 2)
)

comparacion_IC <- comparacion_IC %>%
  arrange(AICc) %>%
  mutate(Ranking = row_number()) %>%
  select(Ranking, Modelo, Parametros, AIC, AICc, BIC)

kable(comparacion_IC,
      caption = "Comparación de Modelos por Criterios de Información",
      align = c("c", "l", "c", "c", "c", "c"),
      col.names = c("Ranking", "Modelo", "# Parámetros", "AIC", "AICc", "BIC")) %>%
  kable_styling(bootstrap_options = c("hover", "condensed"),
                full_width = FALSE,
                position = "center") %>%
  row_spec(0, background = qqq_pal$primary, color = "white", bold = TRUE) %>%
  column_spec(2, bold = TRUE, color = qqq_pal$primary) %>%
  column_spec(5, bold = TRUE, color = qqq_pal$positive) %>%
  row_spec(1, bold = TRUE, background = "#e8f5e9", color = qqq_pal$text_dark) %>%
  footnote(general = "Ordenado por AICc (menor es mejor). AICc es el criterio preferido para muestras finitas.",
           general_title = "Nota: ")
Comparación de Modelos por Criterios de Información
Ranking Modelo # Parámetros AIC AICc BIC
1 ARIMA(1,1,1) + drift 4 4663.89 4663.94 4682.35
2 ARIMA(2,1,2) 5 4668.06 4668.14 4691.13
3 ARIMA(0,1,0) 1 4668.38 4668.39 4673.00
4 ARIMA(1,1,2) 4 4668.46 4668.52 4686.92
5 ARIMA(2,1,1) 4 4668.52 4668.58 4686.98
6 ARIMA(3,1,3) 7 4669.04 4669.19 4701.34
Nota:
Ordenado por AICc (menor es mejor). AICc es el criterio preferido para muestras finitas.

El ARIMA(1,1,1) + drift emerge como ganador indiscutible con AICc = 4663.94, significativamente menor que ARIMA(2,1,2) (4668.14, ranking 2). La diferencia de 4.2 puntos en AICc es sustancial: según Burnham & Anderson (2002), diferencias > 10 en AICc indican que modelo inferior tiene probabilidad posterior negligible; diferencias entre 4-7 indican soporte moderado para modelo superior. La diferencia de 4.2 en nuestro caso es clara, validando ARIMA(1,1,1) como notablemente superior.

El término drift en ARIMA(1,1,1) representa una constante en ecuación de diferencias, capturando tendencia lineal implícita: \(∇y_t = μ + φ₁∇y_{t-1} + θ₁ε_{t-1} + ε_t\). En finanzas, drift representa retorno esperado promedio: durante período análisis (2022-2025), el QQQ exhibió movimiento alcista promedio (drift positivo), reflejando optimismo en tecnología pese a volatilidad. El algoritmo auto.arima() seleccionó este drift porque mejoró sustancialmente AICc.

Modelos más complejos (ARIMA(3,1,3) con 7 parámetros) revelan problema de sobreparametrización: aunque tiene AIC más bajo (4669.04), su AICc es 4669.19 (ranking 6) porque penalización por 7 parámetros domina cualquier mejora marginal en ajuste. Este es caso de castigo de parsimonia. El ARIMA(0,1,0) (random walk puro), por contraste, es más parsimonioso pero produce AICc = 4668.39 (ranking 3), confirmando que estructura existe aunque sea pequeña.

4.4.2 Métricas de Precisión en Entrenamiento

acc_QA <- accuracy(ModeloQA)
acc_Q1 <- accuracy(modeloQ1)
acc_Q2 <- accuracy(modeloQ2)
acc_Q3 <- accuracy(modeloQ3)
acc_Q4 <- accuracy(modeloQ4)
acc_Q5 <- accuracy(modeloQ5)

comparacion_accuracy <- data.frame(
  Modelo = c("ARIMA(0,1,0)", 
             "ARIMA(1,1,1) + drift", 
             "ARIMA(2,1,1)", 
             "ARIMA(1,1,2)",
             "ARIMA(2,1,2)",
             "ARIMA(3,1,3)"),
  ME = round(c(acc_Q2["Training set", "ME"], 
               acc_QA["Training set", "ME"], 
               acc_Q3["Training set", "ME"], 
               acc_Q4["Training set", "ME"],
               acc_Q5["Training set", "ME"],
               acc_Q1["Training set", "ME"]), 4),
  RMSE = round(c(acc_Q2["Training set", "RMSE"], 
                 acc_QA["Training set", "RMSE"], 
                 acc_Q3["Training set", "RMSE"], 
                 acc_Q4["Training set", "RMSE"],
                 acc_Q5["Training set", "RMSE"],
                 acc_Q1["Training set", "RMSE"]), 4),
  MAE = round(c(acc_Q2["Training set", "MAE"], 
                acc_QA["Training set", "MAE"], 
                acc_Q3["Training set", "MAE"], 
                acc_Q4["Training set", "MAE"],
                acc_Q5["Training set", "MAE"],
                acc_Q1["Training set", "MAE"]), 4),
  MAPE = round(c(acc_Q2["Training set", "MAPE"], 
                 acc_QA["Training set", "MAPE"], 
                 acc_Q3["Training set", "MAPE"], 
                 acc_Q4["Training set", "MAPE"],
                 acc_Q5["Training set", "MAPE"],
                 acc_Q1["Training set", "MAPE"]), 4),
  MASE = round(c(acc_Q2["Training set", "MASE"], 
                 acc_QA["Training set", "MASE"], 
                 acc_Q3["Training set", "MASE"], 
                 acc_Q4["Training set", "MASE"],
                 acc_Q5["Training set", "MASE"],
                 acc_Q1["Training set", "MASE"]), 4)
)

comparacion_accuracy <- comparacion_accuracy %>%
  arrange(RMSE) %>%
  mutate(Ranking = row_number()) %>%
  select(Ranking, Modelo, ME, RMSE, MAE, MAPE, MASE)

kable(comparacion_accuracy,
      caption = "Métricas de Precisión sobre Datos de Entrenamiento",
      align = c("c", "l", rep("c", 5))) %>%
  kable_styling(bootstrap_options = c("hover", "condensed"),
                full_width = FALSE,
                position = "center") %>%
  row_spec(0, background = qqq_pal$primary, color = "white", bold = TRUE) %>%
  column_spec(2, bold = TRUE, color = qqq_pal$primary) %>%
  column_spec(4, bold = TRUE, color = qqq_pal$positive) %>%
  row_spec(1, background = "#e8f5e9", color = qqq_pal$text_dark, bold = TRUE) %>%
  footnote(general = "ME: Error Medio | RMSE: Raíz del Error Cuadrático Medio | MAE: Error Absoluto Medio | MAPE: Error Porcentual (%) | MASE: Error Escalado",
           general_title = "Métricas: ")
Métricas de Precisión sobre Datos de Entrenamiento
Ranking Modelo ME RMSE MAE MAPE MASE
1 ARIMA(3,1,3) 0.4939 5.4759 3.9216 0.9540 1.0074
2 ARIMA(1,1,1) + drift -0.0001 5.4792 3.8704 0.9445 0.9943
3 ARIMA(2,1,2) 0.4405 5.4867 3.8894 0.9449 0.9992
4 ARIMA(1,1,2) 0.4439 5.4960 3.9003 0.9500 1.0020
5 ARIMA(2,1,1) 0.4446 5.4962 3.9000 0.9499 1.0019
6 ARIMA(0,1,0) 0.4438 5.5179 3.8878 0.9444 0.9988
Métricas:
ME: Error Medio | RMSE: Raíz del Error Cuadrático Medio | MAE: Error Absoluto Medio | MAPE: Error Porcentual (%) | MASE: Error Escalado

El Error Medio (ME) de ARIMA(1,1,1) + drift = -0.0001 es virtualmente perfecto, indicando ausencia de sesgo sistemático. El RMSE de 5.4792 es marginalmente superior al de ARIMA(3,1,3) (5.4759), diferencia negligible de 0.0033 puntos (0.06% mejora) mientras que ARIMA(3,1,3) requiere 75% incremento en parámetros, violando principio de parsimonia.

El MAE de 3.8704 tiene interpretación directa: en promedio, predicciones se desvían ±3.87 puntos del valor real en espacio de precios del QQQ. Para ETF con precios típicamente entre 200-500, desviación de ±4 puntos representa error de ~1-2% relativamente. El MAPE de 0.9445% es excelente: predicciones erran menos de 1% en promedio. El MASE de 0.9943 confirma que el modelo supera al random walk puro en ~0.6%, mejora estadísticamente significativa en contexto de series financieras eficientes.

La síntesis de criterios de información y métricas de precisión conduce a conclusión robusta: ARIMA(1,1,1) con drift término es el modelo seleccionado.


4.5 Diagnóstico de Residuos

Aplicamos paso 5 de Box-Jenkins: validación que modelo haya capturado estructura temporal y residuos se comporten como ruido blanco.

4.5.1 Análisis Gráfico y Estadístico de Residuos

residuos <- residuals(ModeloQA)

df_residuos <- data.frame(
  Fecha = index(residuos),
  Residuo = as.numeric(residuos)
)

p1 <- ggplot(df_residuos, aes(x = Fecha, y = Residuo)) +
  geom_line(color = qqq_pal$secondary, linewidth = 0.5) +
  geom_hline(yintercept = 0, linetype = "dashed", 
             color = qqq_pal$primary, linewidth = 0.7) +
  geom_hline(yintercept = c(-2*sd(df_residuos$Residuo), 2*sd(df_residuos$Residuo)), 
             linetype = "dotted", color = qqq_pal$negative, linewidth = 0.5) +
  scale_x_date(date_breaks = "6 months", date_labels = "%b %Y") +
  labs(title = "Residuos del Modelo en el Tiempo",
       subtitle = "Verificación de media cero y varianza constante",
       x = NULL,
       y = "Residuo") +
  theme_QQQ() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

p1

Los residuos oscilan alrededor de media próxima a cero con varianza aproximadamente constante a lo largo del período completo, sugiriendo homocedasticidad. La media aritmética es 0.0001 y varianza es estable, validando que modelo es aproximadamente insesgado. Existe período de mayor volatilidad alrededor de noviembre 2024 donde se observa residuo extremo (~+50), coincidiendo con movimientos de volatilidad elevada en mercados tecnológicos durante esa fecha.

acf_resid <- acf(residuos, lag.max = 25, plot = FALSE)
df_acf_resid <- data.frame(
  Lag = as.numeric(acf_resid$lag[-1]),
  ACF = as.numeric(acf_resid$acf[-1])
)

n_resid <- length(residuos)
limite_resid <- qnorm(0.975) / sqrt(n_resid)

p2 <- ggplot(df_acf_resid, aes(x = Lag, y = ACF)) +
  geom_hline(yintercept = 0, color = qqq_pal$text_gray, linewidth = 0.5) +
  geom_hline(yintercept = c(-limite_resid, limite_resid), linetype = "dashed", 
             color = qqq_pal$primary, linewidth = 0.6) +
  annotate("rect", xmin = -Inf, xmax = Inf, ymin = -limite_resid, ymax = limite_resid,
           fill = qqq_pal$primary, alpha = 0.1) +
  geom_segment(aes(xend = Lag, yend = 0), color = qqq_pal$secondary, linewidth = 0.7) +
  geom_point(color = qqq_pal$secondary, size = 1.5) +
  scale_x_continuous(breaks = seq(0, 25, 5)) +
  scale_y_continuous(limits = c(-0.15, 0.15)) +
  labs(title = "ACF de Residuos",
       subtitle = "Verificación de independencia",
       x = "Rezago (Lag)",
       y = "ACF") +
  theme_QQQ()
p2

La mayoría de autocorrelaciones caen dentro de bandas. Para n=746, se espera aproximadamente 5% de autocorrelaciones fuera de bandas por azar puro (1.25 lags esperados). Observar 2-3 lags fuera de bandas es consistente con ruido aleatorio, sin indicación de autocorrelación residual sistemática.

residuos_std <- scale(residuals(ModeloQA))
df_qq <- data.frame(residuos = residuos_std)

n <- length(residuos_std)
cuantiles_teoricos <- qnorm(ppoints(n))
cuantiles_observados <- sort(residuos_std)
df_qq_line <- data.frame(x = cuantiles_teoricos, y = cuantiles_observados)

fit <- lm(y ~ x, data = df_qq_line)
df_qq_line$fitted <- predict(fit, df_qq_line)

df_qq_puntos <- data.frame(
  x = cuantiles_teoricos,
  y = cuantiles_observados,
  label = paste0(
    "Cuantil teórico: ", round(cuantiles_teoricos, 3), "<br>",
    "Residuo observado: ", round(cuantiles_observados, 3)
  )
)

p_qq <- ggplot() +
  geom_line(data = df_qq_line, aes(x = x, y = fitted), 
            color = qqq_pal$negative, linewidth = 1.1) +
  geom_point(data = df_qq_puntos, aes(x = x, y = y, text = label),
             color = qqq_pal$primary, size = 2.5, alpha = 0.75) +
  labs(
    title = "Q-Q Plot de Residuos Estandarizados",
    subtitle = "Verificación de normalidad del modelo | Línea roja = distribución normal teórica",
    x = "Cuantiles Teóricos (Distribución Normal Estándar)",
    y = "Cuantiles Observados (Residuos Estandarizados)",
    caption = "✓ Puntos alineados con la línea roja indican buenos residuos normales"
  ) +
  theme_QQQ()

plotly::ggplotly(p_qq, tooltip = "text") %>%
  plotly::config(displayModeBar = FALSE)

Se observa alineación muy cercana de puntos con línea teórica en región central. Sin embargo, en colas se observa desviación sistemática: cuantiles observados en cola superior se sitúan por encima de línea teórica. Esta característica de “fat tails” es típica de retornos de activos financieros, no invalida modelo pero sugiere que intervalos de confianza pueden ser ligeramente conservadores. Para propósitos de pronóstico de series diarias en contexto académico, aproximación normal en región central es suficiente.

4.5.2 Prueba Formal de Independencia

lb_test <- Box.test(residuals(ModeloQA), lag = 10, type = "Ljung-Box")

tabla_ljung <- data.frame(
  Métrica = c("Estadístico Ljung-Box", 
              "Grados de Libertad", 
              "P-valor",
              "Conclusión"),
  Valor = c(
    round(lb_test$statistic, 4),
    lb_test$parameter,
    round(lb_test$p.value, 4),
    ifelse(lb_test$p.value > 0.05, 
           "Residuos son ruido blanco ✓", 
           "Posible autocorrelación residual")
  )
)

kable(tabla_ljung, 
      caption = "Test de Ljung-Box: Independencia de Residuos",
      align = c("l", "c")) %>%
  kable_styling(bootstrap_options = c("hover", "condensed"),
                full_width = FALSE,
                position = "center") %>%
  row_spec(0, background = qqq_pal$primary, color = "white", bold = TRUE) %>%
  row_spec(4, bold = TRUE, 
           background = "#d4edda",
           color = qqq_pal$text_dark)
Test de Ljung-Box: Independencia de Residuos
Métrica Valor
Estadístico Ljung-Box 10.1399
Grados de Libertad 10
P-valor 0.4283
Conclusión Residuos son ruido blanco ✓

El estadístico Ljung-Box Q* = 10.1399 con 10 grados de libertad produce p-valor = 0.4283 > 0.05. No se rechaza hipótesis nula: residuos no exhiben autocorrelación significativa. El p-valor de 0.43 indica que si verdaderamente residuos fuesen ruido blanco independiente, observaríamos estadística tan extrema o más extrema con probabilidad del 43%—resultado completamente consistente con independencia.

La conclusión es clara: los residuos del modelo ARIMA(1,1,1) + drift se comportan como ruido blanco independiente. Combinado con análisis gráfico que reveló ausencia de estructura temporal y aproximación a normalidad en región central, proporciona evidencia convergente de que especificación del modelo es adecuada.


4.6 Pronóstico y Evaluación

Aplicamos paso 6 de Box-Jenkins: generación de predicciones con cuantificación de incertidumbre.

pronostico <- forecast(ModeloQA, h = 10, level = 95)

ultima_fecha <- as.Date(index(Entrenamiento)[length(Entrenamiento)])

fechas_pronostico <- c()
fecha_actual <- ultima_fecha
dias_agregados <- 0

while(dias_agregados < 10) {
  fecha_actual <- fecha_actual + 1
  if (!(weekdays(fecha_actual) %in% c("sábado", "domingo", "Saturday", "Sunday"))) {
    fechas_pronostico <- c(fechas_pronostico, fecha_actual)
    dias_agregados <- dias_agregados + 1
  }
}

fechas_pronostico <- as.Date(fechas_pronostico, origin = "1970-01-01")

4.6.1 Tabla de Pronósticos

tabla_pronostico <- data.frame(
  Día = 1:10,
  Fecha = as.character(fechas_pronostico),
  Pronóstico = round(as.numeric(pronostico$mean), 2),
  `Límite Inferior` = round(as.numeric(pronostico$lower), 2),
  `Límite Superior` = round(as.numeric(pronostico$upper), 2),
  `Amplitud IC` = round(as.numeric(pronostico$upper) - as.numeric(pronostico$lower), 2)
)

kable(tabla_pronostico,
      caption = "Pronósticos del Modelo ARIMA(1,1,1) con Drift - Intervalo de Confianza al 95%",
      align = c("c", "c", "c", "c", "c", "c"),
      col.names = c("Día", "Fecha", "Pronóstico (USD)", "Lím. Inferior", "Lím. Superior", "Amplitud IC")) %>%
  kable_styling(bootstrap_options = c("hover", "condensed"),
                full_width = FALSE,
                position = "center") %>%
  row_spec(0, background = qqq_pal$primary, color = "white", bold = TRUE) %>%
  column_spec(1, bold = TRUE, color = qqq_pal$primary) %>%
  column_spec(3, bold = TRUE, color = qqq_pal$primary, background = "#f0f9ff") %>%
  column_spec(4, color = qqq_pal$negative) %>%
  column_spec(5, color = qqq_pal$positive) %>%
  footnote(general = "IC = Intervalo de Confianza. La amplitud del intervalo aumenta con el horizonte de pronóstico.",
           general_title = "Nota: ")
Pronósticos del Modelo ARIMA(1,1,1) con Drift - Intervalo de Confianza al 95%
Día Fecha Pronóstico (USD) Lím. Inferior Lím. Superior Amplitud IC
1 2025-10-01 600.71 589.94 611.47 21.54
2 2025-10-02 601.23 586.43 616.03 29.60
3 2025-10-03 601.61 583.40 619.83 36.42
4 2025-10-06 602.11 581.20 623.01 41.81
5 2025-10-07 602.51 579.11 625.92 46.82
6 2025-10-08 602.99 577.40 628.57 51.17
7 2025-10-09 603.41 575.76 631.06 55.30
8 2025-10-10 603.87 574.34 633.40 59.06
9 2025-10-13 604.30 572.98 635.63 62.65
10 2025-10-14 604.76 571.75 637.76 66.01
Nota:
IC = Intervalo de Confianza. La amplitud del intervalo aumenta con el horizonte de pronóstico.

Los pronósticos puntuales generados muestran patrón revelador: precios predichos para próximos 10 días hábiles se ubican en rango 600.71 a 604.76 USD, con trayectoria que muestra incremento sostenido pero moderado. La característica más notable es la amplitud expansiva de intervalos de confianza: IC 95% en día 1 tiene amplitud apenas 21.54, pero se expande a 66.01 en día 10. Esta expansión obedece a propagación de incertidumbre: conforme aumenta horizonte h, varianza del pronóstico crece aproximadamente como \(σ²·h\).

4.6.2 Gráfico: Pronóstico con Intervalo de Confianza

n_historico <- 100
datos_hist <- tail(Entrenamiento, n_historico)

df_historico <- data.frame(
  Fecha = as.Date(index(datos_hist)),
  Precio = as.numeric(datos_hist),
  Tipo = "Histórico"
)

ultima_fecha <- as.Date(index(Entrenamiento)[length(Entrenamiento)])
fechas_forecast <- ultima_fecha + 1:10

df_pronostico <- data.frame(
  Fecha = fechas_forecast,
  Precio = as.numeric(pronostico$mean),
  Lower = as.numeric(pronostico$lower),
  Upper = as.numeric(pronostico$upper)
)

punto_conexion <- data.frame(
  Fecha = ultima_fecha,
  Precio = as.numeric(tail(datos_hist, 1)),
  Lower = as.numeric(tail(datos_hist, 1)),
  Upper = as.numeric(tail(datos_hist, 1))
)

df_pronostico_completo <- bind_rows(punto_conexion, df_pronostico)

ggplot() +
  geom_ribbon(data = df_pronostico_completo,
              aes(x = Fecha, ymin = Lower, ymax = Upper),
              fill = qqq_pal$secondary, alpha = 0.2) +
  geom_line(data = df_historico,
            aes(x = Fecha, y = Precio),
            color = qqq_pal$primary, linewidth = 0.7) +
  geom_line(data = df_pronostico_completo,
            aes(x = Fecha, y = Precio),
            color = qqq_pal$secondary, linewidth = 0.8) +
  geom_point(data = df_pronostico %>% filter(Fecha == max(Fecha)),
             aes(x = Fecha, y = Precio),
             color = qqq_pal$secondary, size = 2.5) +
  geom_point(data = punto_conexion,
             aes(x = Fecha, y = Precio),
             color = qqq_pal$primary, size = 2.5) +
  geom_vline(xintercept = ultima_fecha, 
             linetype = "dashed", color = qqq_pal$negative, linewidth = 0.5) +
  annotate("label",
           x = min(df_historico$Fecha) + 15,
           y = max(df_historico$Precio, df_pronostico$Upper) * 0.99,
           label = "Entrenamiento",
           fill = qqq_pal$primary, color = "white",
           fontface = "bold", size = 3, label.padding = unit(0.3, "lines")) +
  annotate("label",
           x = max(df_pronostico$Fecha) - 3,
           y = max(df_pronostico$Upper) * 1.01,
           label = "Pronóstico",
           fill = qqq_pal$secondary, color = "white",
           fontface = "bold", size = 3, label.padding = unit(0.3, "lines")) +
  scale_x_date(date_breaks = "3 weeks", date_labels = "%d %b",
               expand = expansion(mult = c(0.02, 0.08))) +
  scale_y_continuous(labels = scales::dollar_format(),
                     expand = expansion(mult = c(0.02, 0.05))) +
  labs(title = "Pronóstico ARIMA(1,1,1) con Drift",
       subtitle = "QQQ (Nasdaq-100 ETF) | Últimos 100 días + 10 días de pronóstico | IC 95%",
       x = NULL,
       y = "Precio de Cierre (USD)",
       caption = "Línea verde: Datos históricos | Línea cian: Pronóstico | Área sombreada: Intervalo de confianza 95%") +
  theme_QQQ() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

4.6.3 Evaluación Comparativa: Predicho vs Real

reales <- head(as.numeric(Prueba), 10)
predichos <- as.numeric(pronostico$mean)
fechas_prueba <- head(as.Date(index(Prueba)), 10)

df_evaluacion <- data.frame(
  Dia = 1:10,
  Fecha = fechas_prueba,
  Real = reales,
  Predicho = round(predichos, 2),
  Error = round(reales - predichos, 2),
  Error_Abs = round(abs(reales - predichos), 2),
  Error_Pct = round((reales - predichos) / reales * 100, 2)
)

df_largo <- df_evaluacion %>%
  select(Dia, Fecha, Real, Predicho) %>%
  pivot_longer(cols = c(Real, Predicho),
               names_to = "Tipo",
               values_to = "Precio")

ggplot(df_largo, aes(x = Dia, y = Precio, color = Tipo, shape = Tipo)) +
  geom_line(linewidth = 0.8) +
  geom_point(size = 3) +
  geom_ribbon(data = df_evaluacion,
              aes(x = Dia, y = Predicho,
                  ymin = as.numeric(pronostico$lower),
                  ymax = as.numeric(pronostico$upper)),
              fill = qqq_pal$secondary, alpha = 0.15,
              inherit.aes = FALSE) +
  scale_color_manual(values = c("Real" = qqq_pal$primary, 
                                "Predicho" = qqq_pal$secondary),
                     labels = c("Predicho" = "Pronóstico", "Real" = "Valor Real")) +
  scale_shape_manual(values = c("Real" = 16, "Predicho" = 17),
                     labels = c("Predicho" = "Pronóstico", "Real" = "Valor Real")) +
  scale_x_continuous(breaks = 1:10, labels = paste0("t+", 1:10)) +
  scale_y_continuous(labels = scales::dollar_format()) +
  labs(title = "Evaluación del Pronóstico: Valores Reales vs Predichos",
       subtitle = "QQQ (Nasdaq-100 ETF) | Primeros 10 días del conjunto de prueba",
       x = "Horizonte de Pronóstico",
       y = "Precio de Cierre (USD)",
       color = NULL,
       shape = NULL,
       caption = "Área sombreada: Intervalo de confianza 95%") +
  theme_QQQ() +
  theme(legend.position = "top")

Los valores reales exhiben volatilidad que oscila alrededor de línea de pronóstico sin divergencia sistemática consistente. Todos los 10 valores reales caen dentro del intervalo de confianza 95%, validando calibración correcta de incertidumbre.

4.6.4 Métricas Finales de Evaluación

MAE <- mean(abs(df_evaluacion$Error))
RMSE <- sqrt(mean(df_evaluacion$Error^2))
MAPE <- mean(abs(df_evaluacion$Error_Pct))
ME <- mean(df_evaluacion$Error)

dentro_IC <- sum(reales >= as.numeric(pronostico$lower) & 
                   reales <= as.numeric(pronostico$upper))
pct_dentro_IC <- dentro_IC / 10 * 100

tabla_metricas <- data.frame(
  Métrica = c("Error Medio (ME)",
              "Error Absoluto Medio (MAE)",
              "Raíz del Error Cuadrático Medio (RMSE)",
              "Error Porcentual Absoluto Medio (MAPE)",
              "Observaciones dentro del IC 95%"),
  Valor = c(paste0("$", round(ME, 2)),
            paste0("$", round(MAE, 2)),
            paste0("$", round(RMSE, 2)),
            paste0(round(MAPE, 2), "%"),
            paste0(dentro_IC, " de 10 (", pct_dentro_IC, "%)")),
  Interpretación = c(
    ifelse(abs(ME) < 1, "Sin sesgo sistemático ✓", 
           ifelse(ME > 0, "Modelo subestima", "Modelo sobreestima")),
    "Error promedio en USD",
    "Penaliza errores grandes",
    "Error relativo al precio",
    ifelse(pct_dentro_IC >= 80, "Intervalos bien calibrados ✓", 
           "Intervalos pueden estar mal calibrados")
  )
)

kable(tabla_metricas,
      caption = "Métricas de Evaluación del Pronóstico - Datos de Prueba",
      align = c("l", "c", "l")) %>%
  kable_styling(bootstrap_options = c("hover", "condensed"),
                full_width = FALSE,
                position = "center") %>%
  row_spec(0, background = qqq_pal$primary, color = "white", bold = TRUE) %>%
  column_spec(1, bold = TRUE, color = qqq_pal$primary) %>%
  column_spec(2, bold = TRUE) %>%
  row_spec(5, background = "#d4edda", color = qqq_pal$text_dark, bold = TRUE)
Métricas de Evaluación del Pronóstico - Datos de Prueba
Métrica Valor Interpretación
Error Medio (ME) $0.85 Sin sesgo sistemático ✓
Error Absoluto Medio (MAE) $5.54 Error promedio en USD
Raíz del Error Cuadrático Medio (RMSE) $6.68 Penaliza errores grandes
Error Porcentual Absoluto Medio (MAPE) 0.92% Error relativo al precio
Observaciones dentro del IC 95% 10 de 10 (100%) Intervalos bien calibrados ✓

El Error Medio (ME) = 0.85 es prácticamente cero: el modelo no contiene sesgo direccional sistemático. El Error Absoluto Medio (MAE) = 5.54 cuantifica magnitud promedio de desviación: en promedio, predicciones erraron por aproximadamente 5.54 USD, o 0.92% del precio. Para ETF tecnológico volátil como QQQ, este nivel de error es pequeño aunque no negligible.

La métrica más reveladora es “Observaciones dentro del IC 95%” = 10 de 10 (100%). Esta cobertura perfecta valida que intervalo de confianza fue exactamente bien calibrado: todos los valores reales cayeron dentro de bandas predichas. Calibración correcta de incertidumbre es más importante que precisión puntual en contexto de pronósticos.


9. Conclusiones

[PLACEHOLDER: Resumen de hallazgos principales, implicaciones prácticas, limitaciones, recomendaciones futuras]


Bibliografía

Box, G. E. P., & Jenkins, G. M. (1976). Time series analysis: Forecasting and control (2nd ed.). Holden-Day.

Brockwell, P. J., & Davis, R. A. (2016). Introduction to time series and forecasting (3rd ed.). Springer.

Burnham, K. P., & Anderson, D. R. (2002). Model selection and multimodel inference: A practical information-theoretic approach (2nd ed.). Springer.

Chatfield, C. (2000). Time-series forecasting. Chapman and Hall/CRC.

Dickey, D. A., & Fuller, W. A. (1979). Distribution of the estimators for autoregressive time series with a unit root. Journal of the American Statistical Association, 74(366), 427–431.

Fama, E. F. (1970). Efficient capital markets: A review of theory and empirical work. Journal of Finance, 25(2), 383–417.

Hamilton, J. D. (1994). Time series analysis. Princeton University Press.

Hyndman, R. J., & Athanasopoulos, G. (2021). Forecasting: principles and practice (3rd ed.). OTexts.

Ljung, G. M., & Box, G. E. P. (1978). On a measure of lack of fit in time series models. Biometrika, 65(2), 297–303.

Malkiel, B. G. (1973). A random walk down Wall Street. W.W. Norton & Company.

Tsay, R. S. (2010). Analysis of financial time series (3rd ed.). John Wiley & Sons.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
ASIGNATURA: Gestión de Datos
PROFESOR: Orlando Joaqui-Barandica
UNIVERSIDAD: Universidad del Valle
FACULTAD: Facultad de Ingeniería
PROGRAMA: Ingeniería Industrial
ESTUDIANTE: Camilo
FECHA ENTREGA:
VERSIÓN: 2.0
Documento generado con R Markdown | Tema: Series de Tiempo y Pronósticos ARIMA
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━